sandbox is an unprivileged, low-level sandboxing tool for Linux. It uses
user namespaces
so that an ordinary, unprivileged user can construct a container-like
environment for a single command.
It works by creating a new, completely empty mount namespace where the root is on a tmpfs that is invisible from the host, and which is automatically cleaned up when the last process exits. You then use command-line options to build up the root filesystem, the process environment, and the command to run inside the namespace.
Beyond namespaces it can apply a Landlock ruleset, a built-in seccomp filter, resource limits and an AppArmor transition — restrictions that travel with the process rather than with its view of the filesystem. Those need no external toolchain: there is no libseccomp dependency and no policy compiler.
It is a toolkit for constructing sandboxes, not a ready-made sandbox with a
security policy of its own. The level of isolation between the sandboxed
process and the host is entirely determined by the arguments you pass. Whatever
constructs those arguments — a wrapper script, a configuration file, or a
larger framework — is responsible for defining the security model. See
SECURITY.md.
Linux, and Rust 1.88 or later. Edition 2024 alone would only need 1.85; the floor is let-chains. CI builds on exactly 1.88, so this is a checked number rather than a guess.
There is no minimum kernel version, deliberately. The base sandbox needs
nothing newer than Linux 3.19; each hardening control asks the running kernel
what it can do and refuses loudly if the answer is not enough — --close-fds
wants 5.9, Landlock 5.13, and --unshare cgroup 4.6. The man page has the
table.
cargo install --locked --git https://github.com/necessary-nu/sandboxor, from a checkout:
cargo install --locked --path .Either puts sandbox in ~/.cargo/bin. --locked builds against the
committed Cargo.lock rather than resolving fresh versions, so you get the
dependency set this was tested with; that matters more here than for most
programs, since the ones doing the work are rustix and libc.
To build without installing, cargo build --release writes
target/release/sandbox, which is self-contained and can be copied anywhere on
your PATH.
There is no crates.io release, and cargo install sandbox is not this: that
name there belongs to an unrelated crate.
There is no setuid mode either — the binary refuses to run if it has been made setuid — so the host needs unprivileged user namespaces enabled, or you need to be root.
sandbox [OPTION...] [--] COMMAND [ARG...]A small example that runs a shell command in a namespace reusing the host's
/usr, with fresh /proc and /dev, no network, and a detached terminal
session:
sandbox \
--bind /usr:/usr:ro \
--symlink usr/lib:/lib \
--symlink usr/lib64:/lib64 \
--symlink usr/bin:/bin \
--proc /proc \
--dev /dev \
--unshare all \
--new-session \
--die-with-parent \
/bin/echo "hello from the sandbox"Note that the symlinks above are what a merged-/usr host expects; on a
different layout you will need different ones. They are also not optional
decoration: without them the dynamic loader is missing, and execve fails with
ENOENT naming the program rather than the interpreter it could not find.
There is a fuller demo script that sets up /tmp,
/var, stub passwd/group files and an interactive shell.
The full option reference is in docs/sandbox.1.md, and
sandbox --help lists every option.
Shell completions are generated by the binary itself, so they cannot fall out of step with the options it accepts:
sandbox --completions bash > /usr/share/bash-completion/completions/sandbox
sandbox --completions zsh > /usr/share/zsh/site-functions/_sandbox
fish, elvish and powershell work the same way. There is no automatic
installation.
Namespaces change what the sandboxed process can see. The controls below
change what it can do, and they survive anything it mounts, creates or
re-enters. All of them are refused loudly rather than skipped quietly: a
control the caller asked for and the kernel cannot apply is fatal, because
these have no observable effect when they work, so a sandbox silently missing
one is a sandbox you will trust anyway. --not-a-security-boundary downgrades
that to a warning, and the name is the whole documentation.
Deny-by-default filesystem and TCP access, enforced by the kernel and independent of the mount namespace.
sandbox --bind /:/:dev \
--landlock /usr:read:exec \
--landlock /etc:read \
--landlock /tmp:read:write \
--landlock /dev:read:write \
--landlock-tcp 443:connect \
-- /bin/shRights are read, write, exec and dev for paths, and bind/connect
for ports. Naming one path restricts every other: the whole root is bound above
and only these four are reachable, so /etc is readable but not writable and
/root is not there at all. Grant what the program needs and nothing else;
that is the point of it.
A right may only be granted on a path it applies to. read means "read files
and list directories", and Landlock rejects a directory right on something that
is not one — so --landlock /etc/passwd:read is refused, and the rule belongs
on the directory.
There is one ruleset, not two, so naming only ports still denies the
filesystem: a --landlock-tcp with no --landlock fails at execve, because
nothing granted the right to execute anything.
The ABI is negotiated at run time, and rights the running kernel is too old to
enforce are reported rather than dropped. The ruleset is applied at the last
moment before execve, after the mount namespace is built and capabilities are
gone.
Built-in filters, composable with each other and with any program supplied
through --seccomp FD:
sandbox --bind /:/ --seccomp-profile default -- /bin/shdefaultdeniesptrace,bpf,keyctl,io_uringand TTY injection — the routes out of a namespace sandbox.root-emulationtells a package manager itschown,chmodandmknodsucceeded, so an unprivileged build finishes instead of failing on ownership it was never going to be granted. Useful for building distribution packages without a privileged container; not a security control.
Filters are assembled as classic BPF in-tree, so there is no libseccomp dependency. Every filter begins with an architecture check, without which a syscall-number filter is not a filter at all.
sandbox --bind /:/ --limit nproc=64,nofile=1024,as=2G,core=0 -- /bin/shNames are the ones ulimit and /proc/self/limits use. A limit is never
raised above what the sandbox inherited, only lowered. Where a delegated cgroup
v2 subtree is available the process count is enforced with pids.max rather
than RLIMIT_NPROC, which is counted per-uid and so does not hold a sandbox
that maps more than one uid; without one, RLIMIT_NPROC is used and the
difference is reported.
sandbox --bind /:/ --apparmor-profile sandbox -- /bin/shTransitions the command into an already-loaded profile on execve. The profile
must exist; the sandbox will not load one. apparmor/sandbox
here grants user namespace creation and nothing further, which is what recent
Ubuntu kernels require before an unprivileged sandbox can start at all — and
when one is refused for that reason, the diagnostic says so rather than leaving
you to guess.
A command line long enough to be worth reviewing is one that should not be a command line:
sandbox --config sandbox.toml -- /bin/sh[sandbox]
unshare = ["user", "pid", "ipc", "uts"]
hostname = "example"
seccomp-profiles = ["default"]
[sandbox.limits]
nproc = 64
as = "1G"
[sandbox.landlock]
"/usr" = ["read", "exec"]
"/tmp" = ["read", "write"]
[[op]]
kind = "bind"
src = "/usr"
dst = "/usr"
opts = ["ro"]
[[op]]
kind = "tmpfs"
dst = "/tmp"The document takes effect where --config appears, exactly as though it had
been written there, so there is no precedence to learn: options compose in the
order they appear, and a file is just a long option. Operations are one tagged
array because their order is part of what they mean.
A document is read into the same Config the Rust API builds, which produces
the arguments the ordinary parser reads — so it cannot express anything the
command line cannot, or mean anything different by it. It may not name a file
descriptor, which means something different on every invocation, nor the
command to run, so that handing someone a configuration file is not the same as
handing them a program.
examples/sandbox.toml is a commented document
showing every key.
The crate is usable from Rust directly. No sandbox executable is involved:
spawn forks, and the child does everything the binary does, using the same
parser, so there is one implementation of what the options mean and nothing to
keep in step between a library and a separate program.
use sandbox::config::{Config, Namespaces, Rights, Tmpfs};
use sandbox::limits::Limit;
use sandbox::seccomp_profile::Profile;
let cfg = Config::new()
.unshare(Namespaces::all() - Namespaces::NET)
.bind_ro("/usr", "/usr")
.symlink("usr/bin", "/bin")
.symlink("usr/lib", "/lib")
.symlink("usr/lib64", "/lib64")
.tmpfs(Tmpfs::new("/tmp").perms(0o700))
.seccomp_profile(Profile::Default)
.limit(Limit::Processes, Some(64))
.landlock("/usr", Rights::READ | Rights::EXEC)
.landlock("/tmp", Rights::READ | Rights::WRITE)
.command(["/bin/sh", "-c", "echo hello from the sandbox"]);
// SAFETY: this process is single-threaded; see below.
let sandbox = unsafe { cfg.spawn() }?;
let status = sandbox.wait()?;That is examples/readme_library.rs verbatim,
so it is compiled and run by the build rather than being prose that once
worked: cargo run --example readme_library.
Every control the command line has is reachable here. That direction matters as
much as the other one: a Config that could not express an option would leave
it unreachable from the library and from every configuration file, and
unreachable in the way that is hardest to notice, since the sandbox would still
start and still report success.
Nothing about your process changes. Building a sandbox takes over the process
that does it — it becomes the monitor and reaper for everything inside, blocks
until the sandboxed program exits, blocks SIGCHLD, reaps every zombie it can
find, and latches PR_SET_NO_NEW_PRIVS, which cannot be undone. The fork is
what keeps all of that off you, and it makes the reaping a non-issue for free:
a freshly forked child has no children of its own, so there is nothing of yours
for it to consume.
spawn is unsafe for one reason. Between the fork and the sandboxed
program's execve the child allocates, which is only sound if the forking
process is single-threaded — otherwise another thread may hold the allocator's
lock at the moment of the fork. This is the same hazard as any pre_exec
closure that does real work, and only the caller can know whether it applies.
Ordering is preserved, because it is significant: filesystem operations apply
in the order given, and so do environment operations, where clear_env after a
setenv discards it and before one keeps it.
There is also Config::to_command(path), which returns a
std::process::Command that invokes a separately installed sandbox
executable. That is for driving an installed binary, not for embedding: it
serialises to a command line, so it is subject to ARG_MAX, visible in ps,
and coupled to that binary's option syntax. Prefer spawn.
cargo testNo flags. The tests need unprivileged user namespaces to be available on the host, and nothing else.
One target is unusual and the reason is worth knowing if you add to it.
Config::spawn requires a single-threaded caller, and a libtest harness is a
thread pool — so a #[test] calling it breaks the contract however it is
written, and serialising with a mutex would not help, since the other threads
still exist. tests/spawn.rs therefore sets harness = false and runs those
four tests from its own main. The precondition holds because of how the
binary is built rather than because of how it is invoked, which is why the rest
of the suite can run in parallel and why there is no --test-threads=1 to
remember.
The level of protection is determined by the arguments, so some things need particular care:
-
TTY injection through
TIOCSTIis blocked by--seccomp-profile default, and--new-sessionprevents it as well by detaching the terminal (see CVE-2017-5226). With neither, a sandboxed process can push characters into the terminal it inherited and have your shell run them. -
Everything mounted into the sandbox can potentially be used to escalate privileges. For example, if you bind a D-Bus socket into the sandbox, it can be used to execute commands via systemd. A Landlock ruleset limits what a process can reach regardless of what was mounted, which makes it a useful second answer here, but it does not make a bound socket safe.
-
Some applications deploy their own sandboxing mechanisms, and those can be broken by the constraints imposed here. For example, a web browser that configures its child processes via seccomp cannot do so if the
seccompsyscall is filtered out, or if the rules it wants to load live in a file that is not visible inside the sandbox.
This project began as a Rust port of
bubblewrap by Alexander Larsson,
Colin Walters and the containers project, and is distributed under the same
terms: LGPL-2.0-or-later. See LICENSE for the full text.
It has since diverged: the command line was reorganised, compatibility with upstream is not maintained, and the controls described above have no counterpart there.
Bubblewrap in turn inherited code from xdg-app-helper, which distantly derives from linux-user-chroot.